Skip to content

Select rows and symbols in walkVM by attribution address - #813

Open
rkennke wants to merge 12 commits into
mainfrom
sphinx/fix-jira-PROF-15955
Open

rkennke wants to merge 12 commits into
mainfrom
sphinx/fix-jira-PROF-15955

Conversation

@rkennke

@rkennke rkennke commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Addresses PROF-15955.

Summary

HotspotSupport::walkVM fed the raw walking pc to findLibraryByAddress, findFrameDesc and the DW_REG_PLT stub-offset test. Past the leaf that pc is a return address, so a call that is the last instruction of its caller selected the following function's CFA row, derived a sender sp from it, and could miss a MARK_THREAD_ENTRY sitting on the caller. Same defect #786 fixed in StackWalker::walkFP/walkDwarf — except this is the path CSTACK_DEFAULT actually resolves to whenever the JVM exposes VMStructs on Linux, so it is the common production configuration rather than a fallback.

walkVM now carries the pc and whether it came out of a return-address slot as a
single WalkPc value — the type #786 introduced in walkFP/walkDwarf for
exactly this hazard — and routes the range-based lookups through its
attribution(). Every pc source states what it produced (setReturnAddress,
setRecoveredPc, setExactAddress, setSeed), so adding one is a compile-time
decision rather than something a reviewer has to catch.

Adopting it surfaced a second defect, now also fixed here: walkVM is entered
either from a ucontext (pc is the exact interrupted address) or from
callerPC() (a genuine return address wherever CALLER_PC_IS_RETURN_ADDRESS
holds, i.e. everywhere but aarch64). Both arrived as a bare pointer and were
treated as exact, so the callerPC() entry never got the adjustment. The
private overload now takes that distinction from its caller, the same way
walkFP/walkDwarf seed themselves.

Which pcs get adjusted, and which deliberately do not

Consumer Address used Why
findLibraryByAddress, findFrameDesc attribution range lookups; the whole point of the fix
DW_REG_PLT stub-offset test attribution must be decided on the same address the row was selected with
isContReturnBarrier, isContEntryReturnPc, isEntryFrame raw exact-equality comparisons against return addresses
DW_PC_OFFSET arithmetic raw DW_OP_breg<PC> names the pc register value
no-progress guard, inDeadZone raw needs the exact address
isFrameCompleteAt, findScopeOffset (PcDesc) raw see below

A signal-frame CIE suppresses the adjustment exactly as it does in walkDwarf (pc_is_ra = !f.isSignalFrame()).

Every site in walkVM that writes pc — 16 of them — sets the flag. Two were missing from the original enumeration in the ticket: both in-loop anchor->getFrame(pc, sp, fp) calls silently rewrite pc to lastJavaPC(), which is a return address.

The wire format does not move

resolveNativeFrameForWalkVM was using one address for two jobs: range lookups and the emitted remote-symbolication pc_offset. It now takes the attribution address for findLibraryByAddress/binarySearch while pc_offset keeps deriving from the raw pc, so the emitted wire value is byte-identical.

This matters for review: it means this PR does not depend on the unresolved cross-team question about pc_offset semantics raised in #786. It is effectively option (b) from that PR's own list of options, applied locally.

Two findings that correct the ticket's premises

The suspected PcDesc double-subtraction bug does not exist. PROF-15955 flagged that x64's ad hoc -1 being re-fed into isFrameCompleteAt/findScopeOffset might be a second latent bug. It is not: findScopeOffset (vmStructs.cpp:798) is a ceiling search — on an exact miss it returns the first PcDesc with _pc >= pc_offset — and HotSpot records PcDescs at return addresses, so RA-1 and RA resolve to the same entry. isFrameCompleteAt is a monotone threshold. Both absorb the decrement, so the PcDesc sites are safe on the raw pc.

There is a different, real x86_64 bug — filed as PROF-16033, not fixed here. unwindPrologue/unwindEpilogue/unwindStub fold -1 into the pc they return on x86_64 but not on aarch64, so on x86_64 the three exact-equality comparisons above always fail for pcs arriving through those helpers: continuation-boundary and entry-frame detection silently do not fire. The adjustment is also branch-inconsistent (unwindPrologue's isFrameComplete branch at hotspotStackFrame_x64.cpp:155 omits it), which is why a correct flag cannot be threaded without changing those helpers' contract.

Those three call sites are therefore flagged pc_is_ra = false here — conservative, cannot double-adjust, and no behaviour change on either arch. The arch files are untouched. Fixing them properly means touching x86_64/aarch64 pattern-matching code shared with the legacy getJavaTraceAsync/AsyncGetCallTrace path, which is a different blast radius and belongs in its own change.

Deliberately left untouched

Per the ticket's step 5, calling these out so they do not read as oversights:

  • JVM-internal metadata lookups (CodeHeap::findNMethod, isFrameCompleteAt, findScopeOffset → PcDesc) stay on the raw pc — correct by HotSpot's own convention, confirmed above.
  • unwindPrologue/unwindEpilogue/unwindStub — PROF-16033.
  • unwindCompiled — same inconsistency, but only reachable from the AGCT path, not walkVM. Covered by PROF-16033.
  • PerfEvents::walkKernel — carries a related defect but is outside PROF-15955's scope; it is a pure consumer of the kernel's ring buffer and would be a much smaller separate change.

Test plan

  • buildDebug / buildRelease — clean
  • gtestDebug — 67 test binaries, 0 failures
  • walkVmAttribution_ut.cpp gates the address split (new, 3 tests)
  • walkVM's own per-frame flag threading is still ungated — see below

resolveNativeFrameForWalkVM uses two addresses for two jobs, and both halves
are now pinned at a synthetic zero-gap symbol boundary, the shape where the two
disagree:

test pins
ReturnAddressAtZeroGapBoundaryResolvesToTheCaller flagging the pc as a return address moves the resolved symbol from the following function back to the caller
PcOffsetStaysDerivedFromTheRawPc the same flag leaves the emitted pc_offset untouched — the attribution address is a lookup detail and must not reach the wire
AddressInsideAFunctionResolvesTheSameEitherWay the adjustment is not a blanket shift; well inside a function both flags agree

Both assertions were mutation-checked against the production code:

mutation result
lookups reverted to the raw pc boundary test fails, other two pass
pc_offset derived from the attribution address wire test fails, other two pass
neither all three pass

Each mutation kills exactly its intended test, so the coverage is targeted
rather than incidental.

The fixtures publish synthetic CodeCaches via a new test-only
Libraries::addLibraryForTest() (matching the existing *ForTest idiom in
profiler.h, os.h, callTraceHashTable.h). That is what makes a controlled
zero-gap boundary possible; using the test binary's own symbols cannot produce
one. It also keeps these tests free of the GNU-as/ELF-CFI and
updateSymbols() dependencies that confine returnAddressAttribution_ut to
Linux, so they run on macOS dev builds too.

Still ungated: the 21 per-frame WalkPc mutator calls inside walkVM. The
WalkPc conversion removes the drift hazard (a pc can no longer be assigned
without restating its nature) but does not prove each individual choice is the
right one.

Reaching them from a test is not viable in-process, which I confirmed rather
than assumed: walkVM calls VMThread::current() unconditionally, which asserts
_jvm_thread.isKeyValid() — a key only created by JVMThread::initialize(), which
needs JNI into a live JVM. In a debug gtest that aborts; with the profiler's SIGSEGV
handler installed it becomes an infinite signal loop. Defeating that assert would mean
disabling the very check that says the call is invalid, so I did not.

The coupling is the real problem, and it is now tracked as PROF-16059: walkVM's
dwarf_unwind block and walkDwarf's loop body are two copies of one algorithm
(22 of walkDwarf's 33 code lines appear verbatim in walkVM's 56), and extracting
the shared step yields something testable with synthetic CodeCaches — the mechanism
this PR's new tests already use. That ticket blocks PROF-16033, which has no way to
verify itself for the same reason.

Reviewers: the judgement calls worth pushing back on are (a) the
raw-vs-attribution split in the table above, (b) the new test-only entry point
on Libraries, (c) setExactAddress as the right choice where the arch unwind
helpers already fold in their own adjustment (see PROF-16033), and (d) whether
the remaining walkVM-level gap should block merge.

🤖 Generated with Claude Code

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Pipelines

✨ Unblock PR with BitsAI

❌ Errors

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 17 Pipeline jobs failed

CI Run | test-matrix / test-linux-glibc-aarch64 (11-j9, debug, regular) — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

CI Run | test-matrix / test-linux-glibc-aarch64 (17, debug, regular) — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

CI Run | test-matrix / test-linux-glibc-aarch64 (17-graal, debug, regular) — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

View all 17 failed jobs.

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: a1a4480 | Docs | View more details | Give us feedback!

@dd-octo-sts

dd-octo-sts Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #36152227381 | Commit: 24cb73a | Duration: 14m 54s (longest job)

❌ 14 of 32 test jobs failed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ❌ ✅ - -
8-librca - - ❌ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ❌ ✅ - -
11-librca - - ❌ ✅
17 ❌ ✅ - -
17-graal ❌ ✅ - -
17-j9 ❌ ✅ - -
17-librca - - ❌ ✅
21 ❌ ✅ - -
21-graal ❌ ✅ - -
21-librca - - ❌ ✅
25 ❌ ✅ - -
25-graal ❌ ✅ - -
25-librca - - ❌ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Failed Jobs

Summary: Total: 32 | Passed: 18 | Failed: 14


Updated: 2026-09-25 15:27:41 UTC

@rkennke
rkennke force-pushed the sphinx/fix-jira-PROF-15955 branch from a78aa75 to 36ff97c Compare September 22, 2026 16:48
@dd-octo-sts

dd-octo-sts Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

✅ All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 61959b06

Base automatically changed from sphinx/fix-jira-PROF-15934 to main September 22, 2026 18:42
@rkennke
rkennke force-pushed the sphinx/fix-jira-PROF-15955 branch from 36ff97c to 09d5f7d Compare September 23, 2026 13:41
@rkennke
rkennke marked this pull request as ready for review September 24, 2026 12:31
@rkennke
rkennke requested a review from a team as a code owner September 24, 2026 12:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63de2f9c71

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if (nm->isFrameCompleteAt(walk_pc.raw())) {
const void* epilogue_pc = walk_pc.raw();
if (depth == 1 && frame.unwindEpilogue(nm, (uintptr_t&)epilogue_pc, sp, fp)) {
walk_pc.setExactAddress(epilogue_pc);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve return-address state after arm64 JIT unwinding

On supported arm64, unwindEpilogue, unwindPrologue, and unwindStub assign pc from StackFrame::link(), which is the caller's raw return address; this records it as exact, so the following native resolution and DWARF lookup do not subtract one byte. A sample that leaves a generated frame into a native caller whose call site ends at a symbol/FDE boundary is therefore still attributed to the following symbol/row (the same classification is repeated at lines 676 and 732). Mark these outputs as return addresses, or have the unwind helpers report whether they already adjusted the PC.

AGENTS.md reference: AGENTS.md:L199-L203

Useful? React with 👍 / 👎.

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: PASS

More details

The change keeps raw PCs for exact comparisons and wire offsets. It uses attribution PCs for library, symbol, and unwind-row range lookups.

Was this helpful? React 👍 or 👎

Open Bits AI session

🤖 Bits Code Review · Commit 63de2f9 · @DataDog review to ask questions

@rkennke rkennke added the sphinx:spotcheck Sphinx: spot-check recommended label Sep 24, 2026
rkennke added a commit that referenced this pull request Sep 24, 2026
The three arch unwind helpers were all treated as having already applied the
attribution adjustment. That holds on x86_64, which subtracts one inside the
helper, but not on aarch64: every branch walkVM can reach there assigns the
link register or a saved-pc slot, both raw return addresses. The one aarch64
branch that does subtract is guarded by `&pc == &this->pc()`, true only on the
AsyncGetCallTrace path where the caller passes the frame's own pc rather than a
local, so walkVM never takes it.

A sample leaving a generated frame for a native caller whose call is the last
instruction before a symbol or FDE boundary was therefore still attributed to
whatever follows, on arm64, for exactly the frames this change set out to fix.

recordUnwoundPc() names the difference in one place. x86_64 behaviour is
unchanged; the distinction disappears once the helpers agree on a contract.

Reported by Codex review on #813.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rkennke
rkennke force-pushed the sphinx/fix-jira-PROF-15955 branch from 33f8634 to 77538df Compare September 24, 2026 15:27
rkennke and others added 6 commits September 25, 2026 12:05
walkVM fed the raw walking pc to findLibraryByAddress, findFrameDesc and the
DW_REG_PLT stub-offset test. Past the leaf that pc is a return address, so a
call that is the last instruction of its caller selected the following
function's CFA row, derived a sender sp from it, and could miss a
MARK_THREAD_ENTRY sitting on the caller -- the same defect already fixed in
StackWalker::walkFP/walkDwarf, on the path CSTACK_DEFAULT actually resolves to.

Track per-frame whether the pc came out of a return-address slot and route the
range-based lookups through attributionPC(). Exact-address consumers
(isContReturnBarrier, isContEntryReturnPc, isEntryFrame), the DW_PC_OFFSET
arithmetic and the no-progress guard keep the raw pc, and a signal-frame CIE
suppresses the adjustment exactly as it does in walkDwarf.

resolveNativeFrameForWalkVM was using one address for two jobs. It now takes
the attribution address for findLibraryByAddress/binarySearch while the emitted
pc_offset keeps deriving from the raw pc, so the remote-symbolication wire
value is unchanged and no cross-team contract moves.

unwindPrologue/unwindEpilogue/unwindStub are left alone: x86_64 already folds
the adjustment into the pc they return and does so inconsistently (the
isFrameComplete branch omits it) while aarch64 returns it raw, so their results
are flagged as non-return-addresses and cannot be adjusted twice. Unifying that
contract also fixes the exact-address comparisons those helpers currently break
on x86_64, and is left to its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…resolution

resolveNativeFrameForWalkVM uses two addresses for two jobs: range lookups key
off the attribution address so a call that is the last instruction of its
caller still resolves to the caller, while the emitted pc_offset keeps deriving
from the raw pc because its meaning is a wire contract rather than a local
detail. Neither half was gated by a test.

Both are now pinned at a synthetic zero-gap symbol boundary, the shape where
the two addresses disagree: flagging the pc as a return address must move the
resolved symbol from the following function back to the caller, and must leave
the emitted offset untouched. A third case pins that an address well inside a
function resolves identically either way, so the adjustment cannot be read as a
blanket shift.

Both assertions were mutation-checked -- reverting the lookup to the raw pc
fails the boundary case alone, and deriving pc_offset from the attribution
address fails the wire case alone.

The fixtures publish synthetic CodeCaches through a new test-only entry point
rather than the test binary's own symbols, which is what makes a controlled
zero-gap boundary possible at all and keeps these tests free of the
GNU-as/ELF-CFI and updateSymbols() dependencies that confine
returnAddressAttribution_ut to Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
walkVM tracked "is this pc a return address?" in a bool sitting beside the pc,
so the two could drift: a newly added `pc = ...` inherited whatever the
previous frame had set, silently attributing the next lookup to the wrong
address. That is the exact hazard WalkPc was introduced for in walkFP/walkDwarf,
and walkVM is the default cstack path, so it is the one that most needed it.

walkVM now drives a WalkPc. Every pc source states what it produced --
setReturnAddress for a saved-pc slot or link register, setRecoveredPc for a
DWARF row, setExactAddress where the arch unwind helpers already folded the
adjustment in -- and consumers read raw() or attribution() explicitly. Adding a
pc source is now a compile-time decision instead of something a reviewer has to
notice.

Threading the seed through surfaced a real gap: walkVM is entered either from a
ucontext, whose pc is the exact interrupted address, or from callerPC(), which
is a genuine return address on every architecture where
CALLER_PC_IS_RETURN_ADDRESS holds. Both arrived as a bare pointer and were
treated as exact, so the callerPC() entry never got the adjustment it needed.
The private overload now takes that distinction from its caller, the same way
walkFP and walkDwarf seed themselves.

No behaviour change otherwise: the mutator chosen at each site reproduces the
flag that site already set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the call

walkVM's public entry point built its two starting states inline, so the pc and
the flag describing it were separate arguments chosen a few tokens apart in two
near-identical call expressions. Nothing bound them together, which is the same
drift the walk itself now avoids by carrying a WalkPc.

Both states are one named value instead. walkVMSeed() decides where a walk
starts -- register set and the nature of the pc together -- and the entry point
delegates without a branch of its own.

The frame no longer outlives the decision: its pc/sp/fp are copied out as
scalars, which is all the callee ever used, and the ucontext they point into
outlives the walk regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review follow-ups.

addLibraryForTest shipped in every build. Every other test hook in this
codebase is either behind #ifdef UNIT_TEST or reached through a friend
*TestAccessor, so a publicly callable mutator for the process-wide library set
was both inconsistent and present where nothing should be able to call it. It
is now compiled only into gtest binaries, which is where its one caller lives.

prev_native_pc lost its last reader when the walk started carrying the previous
frame as a WalkPc; the declaration stayed behind. Unused-variable warnings are
not errors here, so nothing caught it.

RemoteSymbolication.md still described the two-argument
resolveNativeFrameForWalkVM. It now names the third parameter and says what it
selects -- lookups move to the attribution address, the emitted pc_offset does
not -- since the wire value being unchanged is the part a reader of that
document needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three arch unwind helpers were all treated as having already applied the
attribution adjustment. That holds on x86_64, which subtracts one inside the
helper, but not on aarch64: every branch walkVM can reach there assigns the
link register or a saved-pc slot, both raw return addresses. The one aarch64
branch that does subtract is guarded by `&pc == &this->pc()`, true only on the
AsyncGetCallTrace path where the caller passes the frame's own pc rather than a
local, so walkVM never takes it.

A sample leaving a generated frame for a native caller whose call is the last
instruction before a symbol or FDE boundary was therefore still attributed to
whatever follows, on arm64, for exactly the frames this change set out to fix.

recordUnwoundPc() names the difference in one place. x86_64 behaviour is
unchanged; the distinction disappears once the helpers agree on a contract.

Reported by Codex review on #813.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rkennke
rkennke force-pushed the sphinx/fix-jira-PROF-15955 branch from 77538df to a924a47 Compare September 25, 2026 10:06
rkennke and others added 2 commits September 25, 2026 13:10
The two copies of this unwind step have drifted. Each gap is taken from
walkDwarf, which is the one that got the attention:

  - The link register was fed to findFrameDesc unstripped. On aarch64 it
    carries PAC bits, so the lookup address is nonsense. walkDwarf strips it
    and says why; this side never picked that up. Only reachable through a
    frame table carrying DW_LINK_REGISTER, which DwarfParser does not emit
    and SFrameParser does, so it is latent rather than live today.

  - The frame-pointer slot was dereferenced without checking its alignment,
    while the pc slot three lines below is checked. Same load, same exposure.

  - Neither slot load carried a fault-injection hook, so the recovery path
    around them went unexercised. LIKELY matches the existing convention:
    walkVM's seven UNLIKELY sites are raw dereferences, these two go through
    SafeAccess.

Reconciling these first keeps the extraction that follows a pure move, with
no behaviour hidden inside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
walkVM read its VMThread through JVMThread::current(), which asserts its
thread-local key is live -- something only a JVM attach sets up. Nothing below
that line needs the thread on the native path, and every use of it is already
NULL-guarded, so it now asks whether one exists instead of requiring it. With a
JVM attached the answer is always yes and nothing changes; without one the walk
takes the same degraded path it would on an unattached thread, and
WALKVM_NO_VMTHREAD still records that it did.

That is the whole of what stood between walkVM and a unit test. VMStructs stays
uninitialised, so CodeHeap::contains() is false for every address and the walk
goes down the native branch on every frame -- which is the branch the
attribution work touches.

The test plants one synthetic linked frame so the walk steps off an exact
ucontext leaf onto a caller whose pc comes out of a return-address slot. Both
land on the same zero-gap boundary, so they must resolve to different functions
purely because of that difference. Mutating the DWARF recovery to claim its pc
is exact fails it, reporting the following function where the caller belongs.

Two platform details the fixture has to respect, both found the hard way:
uc_mcontext is a pointer on Darwin and embedded on Linux, so a zeroed
ucontext_t needs storage to point at or the first register access faults in a
loop behind the profiler's own SIGSEGV handler; and the frame layout has to
come from FrameDesc::default_frame, which is what a table-less CodeCache
returns, not fallback_default_frame(), which differs from it on Apple aarch64.

This covers the DWARF recovery path only. The saved-pc slots in the compiled
and stub arms sit behind CodeHeap::contains() and stay unreachable here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rkennke

rkennke commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Handover — PROF-16059 progress, resuming on Linux

Recording state here rather than as a file in the tree, per the "no specs in repo" convention: this is a snapshot, not something anyone should have to maintain.

Where this stands

Branch sphinx/fix-jira-PROF-15955, 8 commits. The last three are PROF-16059 work rather than PROF-15955:

commit what
61959b06f realign walkVM's DWARF step with walkDwarf's (three divergences)
66eea0c03 Step 1 + Step 2 — JVM decoupling, and walkVM driven end to end from a gtest

Everything below a924a47a2 is the original PROF-15955 attribution work.

Step 1 — done

walkVM now reads its VMThread as JVMThread::isInitialized() ? VMThread::current() : nullptr.

VMThread::current() asserts its thread-local key is live, which only a JVM attach sets up — that assert was the single thing preventing walkVM from being entered at all outside a JVM. I audited the whole prologue to confirm nothing else gates entry: VM::isHotspot() is settable from tests, ProfiledThread::acquireCurrent() works there, and InterpreterFrame::bcp_offset() is a plain field read.

Behaviour with a JVM attached is unchanged by construction — isInitialized() is true, so the same call happens. All 17 uses of vm_thread below were already NULL-guarded except vm_thread->contEntry(), which sits behind CodeHeap::contains() and is unreachable on the native path. WALKVM_NO_VMTHREAD still fires, so the observability the assert provided is not lost.

Step 2 — done, and mutation-checked

WalkVmNativePathTest.CallerFrameFromReturnSlotAttributesToTheCaller drives walkVM end to end with no JVM. VMStructs stays uninitialised, so CodeHeap::contains() is false for every address (its sentinels are inverted: NO_MIN_ADDRESS is (void*)-1, NO_MAX_ADDRESS is (void*)0) and the walk takes the native branch on every frame.

Mutating the DWARF recovery to setExactAddress fails the test, reporting walkvm_attr_second — the following function — where the caller belongs. So it genuinely gates the behaviour.

Scope, precisely: this covers the DWARF recovery path only. I checked: mutating the two sp[-FRAME_PC_SLOT] sites in the compiled and stub arms does not fail it, because those sit behind CodeHeap::contains(). Reaching them needs a fake CodeHeap, which is a separate piece of work.

Two platform traps, both cost real time

uc_mcontext is a pointer on Darwin, embedded on Linux. A zeroed ucontext_t is directly usable on Linux; on macOS StackFrame's first register access dereferences NULL, and with the profiler's SIGSEGV handler installed that faults in a loop rather than crashing. The fixture now gives it a _STRUCT_MCONTEXT64 to point at under #ifdef __APPLE__. This is why returnAddressAttribution_ut's fixtures are __linux__-gated — that gate is load-bearing, not incidental.

A table-less CodeCache returns FrameDesc::default_frame, not fallback_default_frame(). They differ on Apple aarch64 (default_clang_frame). The fixture's planted frame must use the former or the walker reads the wrong slots.

Next: Step 3 — extract advanceDwarfFrame

Shape proposed in the ticket:

bool advanceDwarfFrame(WalkPc& pc, uintptr_t& sp, uintptr_t& fp,
                       uintptr_t bottom, CodeCache* cc, const StackFrame& frame);

61959b06f removed the three semantic divergences first, so the extraction should now be a pure move with no behaviour hidden in it. What stays at the call sites: walkVM's prev_native_pc bookkeeping and fp_chain_fallback, and walkDwarf's JIT/java_ctx handoff.

Why Linux is the better place to continue

  • lldb attach is blocked by macOS security, which cost a diagnostic round here; gdb works.
  • The __linux__-gated fixtures in returnAddressAttribution_ut compile and run there, so the compiled/stub arms become reachable and the coverage gap above can be closed.
  • It is the platform the walker actually runs on in production.

Running it

./.claude/commands/build-and-summarize gtestDebug_walkVmAttribution_ut
./.claude/commands/build-and-summarize gtestRelease          # 68 binaries

Full suite is green in both configs on macOS/arm64: 68 binaries, 0 failures.

Caveats

Everything above was verified on macOS/arm64 only. CI now runs release gtests on both arches (#821), so the Linux picture arrives with the next pipeline on this branch.

The Libraries::addLibraryForTest() seam these fixtures depend on is #ifdef UNIT_TEST, so it does not ship.

jbachorik and others added 4 commits September 25, 2026 15:43
…step

61959b0 reconciled three gaps between walkVM's copy of the DWARF unwind
step and walkDwarf's, all of them in the pc/fp recovery half. A mechanical
diff of the two bodies shows the CFA half still diverges in two places, and
unlike the earlier three these resolve in opposite directions -- so the
extraction that follows could not have been the pure move that commit
claimed it was setting up.

walkVM falls through on a CFA register it does not implement.

walkDwarf ends its cfa_reg chain with an else that stops the walk. walkVM
instead pre-screened DW_REG_INVALID and anything past DW_REG_PLT, then ran
the same three arms with no else. With DW_REG_FP 6, DW_REG_SP 7 and
DW_REG_PLT 128 on x86_64, a register in 0..5 or 8..127 matches no arm and is
not rejected: sp keeps the value it had on entry to the step, and the walk
reads the caller's pc and fp out of the current frame's own slots.

It is reachable from ordinary DWARF. DwarfParser takes the register straight
off the wire under DW_CFA_def_cfa, _def_cfa_register and _def_cfa_sf, and
addRecord() only rejects it above 0xFF, so nothing narrows it to the three
the walkers implement -- a function whose CFA is based on any other register
lands in the hole. WalkVmAlsoStopsOnAnUnhandledCfaRegister plants exactly
that row and shows walkVM recording a second frame off an unmoved sp where
walkDwarf stops at the leaf.

walkVM's pre-screen is subsumed by the new else and goes away with it; every
value it rejected now falls out of the same arm as the rest.

walkDwarf trusts a frame pointer walkVM checks.

The other direction: walkVM sanity-checks fp for bounds and alignment before
deriving a CFA from it, walkDwarf did not. A misaligned fp whose row offset
re-aligns the result slips past every later test, all of which look at sp,
so only a check on fp itself stops it. 61959b0's rule was that each gap is
taken from walkDwarf; this one goes the other way, and the comment travels
with the code so the reason is not lost.

Both changes are gated, each verified to fail with its fix reverted:

  WalkDwarfStopsOnAnUnhandledCfaRegister      baseline, passes throughout
  WalkVmAlsoStopsOnAnUnhandledCfaRegister     gates the else
  WalkDwarfRejectsAMisalignedFramePointer     gates the fp check

Two properties of the fixture are load-bearing and commented as such:
DW_PC_OFFSET is 1, so fp_off must be even or the step takes the DW_OP_breg
arithmetic branch instead of the memory-slot one under test; and cfa_off must
be non-zero or the aarch64 defaultSenderSP() branch fires. A DW_REG_INVALID
row above each planted leaf stops whichever walker does step past it, so a
failure reports a wrong depth rather than running into unplanted memory.

With this the two bodies differ only in where attribution_pc is declared and
in walkVM's prev_native_walk_pc/have_prev_native_pc bookkeeping, which stays
at the call site. advanceDwarfFrame() can now be extracted as a pure move.

Full native suite green on linux-x64 in debug and release, and this binary
green in all four configurations. That is also the first Linux run of the
walkVM fixtures, which had only been exercised on macOS/arm64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rame

walkDwarf and walkVM carried two copies of the same unwind step. 61959b0
and 06ab5b6 reconciled the five differences between them; this moves what
is left into one always_inline helper in dwarfStep.inline.h and calls it from
both. 183 lines of duplication become 10.

always_inline is load-bearing, not an optimisation. AddressSanitizer
instruments per function after inlining, so an inlined body takes its
caller's sanitizer attribute. walkVM is no_sanitize("address") because it
reads arbitrary stack memory while sampling; walkDwarf is deliberately
instrumented, and both have been that way since before the two were split
apart in #451. Out of line, one attribute would have to serve both -- either
dropping walkDwarf's instrumentation or adding to walkVM the instrumentation
the attribute exists to avoid. Measured on the built objects rather than
assumed: with always_inline the helper emits no out-of-line symbol at all,
walkVM still contains zero __asan references and walkDwarf still contains
its own.

What moved and what did not:

  - Both callers keep their own `bottom`, computed from their own frame, and
    pass it in.
  - `depth` is a parameter because the two callers disagree on it. walkVM's
    fp-chain fallback reaches the step through `goto dwarf_unwind`, which
    skips its fillFrame(frames[depth++], ...), so on that path it arrives one
    lower than walkDwarf does at the same logical position. Passing it
    through is what keeps this a move rather than a merge; the header says so
    at the parameter.
  - The lookup goes inside, so the helper takes Profiler* rather than the
    CodeCache* the ticket sketched: the DW_REG_PLT arm needs attribution_pc
    to decide the stub offset, so a caller-side lookup would have to
    recompute walk_pc.attribution() anyway.
  - walkVM's prev_native_walk_pc/have_prev_native_pc bookkeeping stays at the
    call site, hoisted to just above the call from the middle of the step.
    Nothing between those two points mutates walk_pc, and a stop leaves the
    loop, so the only reader -- the MARK_THREAD_ENTRY check on the next
    iteration -- cannot tell the difference.

Each of the nine break sites becomes a `return false`; the count was checked
mechanically before and after rather than by eye, since four of them sit on
arms no test in the suite reaches.

Equivalence was checked against the disassembly, not only the tests.
walkDwarf's call sequence is identical instruction-for-instruction in
release; walkVM's call multiset is identical with one static-guard pair
reordered. Neither is byte-identical -- inlining a function changes what the
scheduler and register allocator see, so release moves 313 -> 308
instructions in walkDwarf and 2997 -> 3061 in walkVM. The sanitizer configs
compile at -O0, where the helper's parameters and its FrameDesc copy
materialise as real stack traffic, so walkDwarf grows there (1020 -> 1173
instructions, 9 -> 25 instrumented accesses). Those configs are test-only;
the shipped release build is the one above.

Full native suite green on linux-x64 in debug and release, and the three
binaries over this code green under ASan and TSan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…test fails

Revert before merge. Diagnostic only, no product change.

gtestDebug_walkVmAttribution_ut fails on every aarch64 cell and has since
66eea0c, while amd64 is green in every configuration. Which assertion
fails is not recoverable from the logs: the Gradle daemon swallows the test
binary's stdout, so the job reports only the task name. .gitlab/sanitizer-tests
already works around this by using Gradle for compile+link and then running
each binary straight from the shell; this does the same for one binary on the
glibc-aarch64 job.

Placed before the Test step, which would otherwise fail the job first, and
marked continue-on-error so it can only add output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sphinx:spotcheck Sphinx: spot-check recommended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants